This document introduces the idea of a color dyad and shows how to use it in a simple channel-based image mapping process. All examples are written in language-neutral pseudocode, so they can be implemented in any language.
A color dyad is simply a pair of colors:
// A dyad is a pair: (ColorA, ColorB)
Dyad = (ColorA, ColorB)
Think of it as a tiny color gradient with only two endpoints.
For an input value t in the range [0, 1], we interpolate:
Color Interpolate(Color A, Color B, number t in [0, 1]):
result.r = A.r * (1 - t) + B.r * t
result.g = A.g * (1 - t) + B.g * t
result.b = A.b * (1 - t) + B.b * t
result.a = A.a * (1 - t) + B.a * t
return result
When we feed a grayscale or normalized channel value into this interpolation, we get a color somewhere along the dyad.
Suppose we have an image with pixels containing (r, g, b, a), each in [0, 1].
We can assign a separate dyad to each channel:
// One dyad for each input channel
DyadR = (ColorR_A, ColorR_B) // used with input r
DyadG = (ColorG_A, ColorG_B) // used with input g
DyadB = (ColorB_A, ColorB_B) // used with input b
For each pixel, we:
in = (r, g, b, a).Image ProcessWithChannelDyads(Image inputImage,
Dyad DyadR, // (ColorR_A, ColorR_B)
Dyad DyadG, // (ColorG_A, ColorG_B)
Dyad DyadB): // (ColorB_A, ColorB_B)
width = inputImage.width
height = inputImage.height
outputImage = NewImage(width, height)
for y from 0 to height - 1:
for x from 0 to width - 1:
inPixel = inputImage.getPixel(x, y)
r = inPixel.r // [0, 1]
g = inPixel.g // [0, 1]
b = inPixel.b // [0, 1]
a = inPixel.a // [0, 1]
// Map each channel through its dyad
pseudoR = Interpolate(DyadR.A, DyadR.B, r)
pseudoG = Interpolate(DyadG.A, DyadG.B, g)
pseudoB = Interpolate(DyadB.A, DyadB.B, b)
// Combine them (simple average)
outColor.r = (pseudoR.r + pseudoG.r + pseudoB.r) / 3
outColor.g = (pseudoR.g + pseudoG.g + pseudoB.g) / 3
outColor.b = (pseudoR.b + pseudoG.b + pseudoB.b) / 3
outColor.a = a // preserve original alpha
outputImage.setPixel(x, y, outColor)
return outputImage
Many variations are possible: you can average the colors as shown, or use channel weights, or even choose just one of the pseudo colors.
Here are some concrete dyad setups that a reader can try.
Colors are written as (r, g, b, a) in [0, 1].
// Dark cool blue to bright warm yellow
DyadR.A = (0.0, 0.0, 0.2, 1.0) // deep blue
DyadR.B = (1.0, 0.9, 0.4, 1.0) // warm yellow
DyadG.A = (0.0, 0.0, 0.2, 1.0)
DyadG.B = (1.0, 0.9, 0.4, 1.0)
DyadB.A = (0.0, 0.0, 0.2, 1.0)
DyadB.B = (1.0, 0.9, 0.4, 1.0)
// Use the same dyad for all three channels
output = ProcessWithChannelDyads(input, DyadR, DyadG, DyadB)
This produces a coherent, single-gradient recoloring: darker regions move toward deep blue, highlights toward warm yellow.
// Each channel has a different dyad, giving more complex mixtures.
// Red channel: purple → orange
DyadR.A = (0.5, 0.0, 0.5, 1.0)
DyadR.B = (1.0, 0.5, 0.0, 1.0)
// Green channel: teal → lime
DyadG.A = (0.0, 0.5, 0.5, 1.0)
DyadG.B = (0.6, 1.0, 0.4, 1.0)
// Blue channel: navy → cyan
DyadB.A = (0.0, 0.0, 0.3, 1.0)
DyadB.B = (0.0, 0.9, 1.0, 1.0)
output = ProcessWithChannelDyads(input, DyadR, DyadG, DyadB)
Here, each input channel pushes the pixel toward a different zone in color space. Averaging them creates rich, layered hues.
You can also randomize your dyads within certain ranges to explore many mappings:
function RandomColor(maxBrightness):
c.r = Random(0, maxBrightness)
c.g = Random(0, maxBrightness)
c.b = Random(0, maxBrightness)
c.a = 1.0
return c
for iteration from 0 to N - 1:
// Example: weaker "A" colors, stronger "B" colors
weightA = 0.5
weightB = 1.0
DyadR.A = RandomColor(weightA)
DyadR.B = Normalize(RandomColor(weightB)) // Optional normalization
DyadG.A = RandomColor(weightA)
DyadG.B = Normalize(RandomColor(weightB))
DyadB.A = RandomColor(weightA)
DyadB.B = Normalize(RandomColor(weightB))
output = ProcessWithChannelDyads(input, DyadR, DyadG, DyadB)
SaveImage(output, "output_" + iteration + ".png")
This produces a set of images, each with its own idiosyncratic color world.
A dyad is a palette of length 2. We can generalize this to a full palette: an ordered array of colors.
// Palette is an array of colors: [C0, C1, ..., Cn-1]
Palette = array of Color
We then stretch our input value t in [0, 1] across the entire array.
Color SamplePalette(Palette P, number t in [0, 1]):
n = length(P)
if n == 0:
return (0, 0, 0, 1) // or some default
if n == 1:
return P[0]
// Scale t to segment index
scaled = t * (n - 1)
indexLow = floor(scaled)
indexHigh = min(indexLow + 1, n - 1)
localT = scaled - indexLow // fractional part in [0, 1]
return Interpolate(P[indexLow], P[indexHigh], localT)
Now, instead of a dyad per channel, we can define a palette per channel.
Image ProcessWithChannelPalettes(Image inputImage,
Palette PaletteR,
Palette PaletteG,
Palette PaletteB):
width = inputImage.width
height = inputImage.height
outputImage = NewImage(width, height)
for y from 0 to height - 1:
for x from 0 to width - 1:
inPixel = inputImage.getPixel(x, y)
r = inPixel.r
g = inPixel.g
b = inPixel.b
a = inPixel.a
pseudoR = SamplePalette(PaletteR, r)
pseudoG = SamplePalette(PaletteG, g)
pseudoB = SamplePalette(PaletteB, b)
outColor.r = (pseudoR.r + pseudoG.r + pseudoB.r) / 3
outColor.g = (pseudoR.g + pseudoG.g + pseudoB.g) / 3
outColor.b = (pseudoR.b + pseudoG.b + pseudoB.b) / 3
outColor.a = a
outputImage.setPixel(x, y, outColor)
return outputImage
// Example palette with 4 colors: black → blue → magenta → white
PaletteR = [
(0.0, 0.0, 0.0, 1.0),
(0.0, 0.0, 0.6, 1.0),
(0.6, 0.0, 0.6, 1.0),
(1.0, 1.0, 1.0, 1.0)
]
// Use same palette for all channels
PaletteG = PaletteR
PaletteB = PaletteR
output = ProcessWithChannelPalettes(input, PaletteR, PaletteG, PaletteB)
Here, the input intensity walks through multiple “stations” in color space, giving more nuanced control than a simple dyad.